7.2 泛型的使用与实现分析

泛型是Go 1.18引入的概念,在引入这个概念前经过了好几年的考量最终才将这这个特性加进去。

泛型在多种语言中都是存在的,比如C++Java等语言中都有泛型的概念。

本节我们将针对泛型的使用、实现原理进行整体的讲解。

本节代码存放目录为 lesson20

泛型基础

什么是泛型?

简单来说,泛型与空接口interface{}相似但又有不同。我们知道空接口可以用来标识任意的类型,其实泛型也是干这件事情的。

那么既然有了接口,为什么还要出现泛型呢?这需要结合我们之前章节的反射来一起看待。

在我们使用interface{}与反射来进行处理时,其实都是在运行时处理,运行时处理那么不可避免的就会出现性能开销与安全性问题。

而泛型则是在编译阶段进行处理的,而不是运行时处理,所以不管是从性能还是安全性来说,泛型都是一种更好的选择。

另外泛型主要用于函数与类型的的定义,而不能用于普通变量的定义,这也是与interface{}的主要区别。

泛型的主要应用是在函数的定义上。当我们有一些公用函数,比如说:打印、求和等,在没有泛型的时候,我们需要定义一个参数为int型的Print、一个参数为string型的Print,但是如果是泛型的话我们只需要定义一个即可。


泛型函数的实现与应用

  • 简单泛型函数

    func Print[T any](input T) {
       fmt.Println(input)
    }
    
    Print(1)
    Print("hello")
    

    在上面的代码中,我们通过泛型仅定义了一个函数,即实现了传递intstring参数的目的。

    定义的格式也是固定的:func funcName[T any](arg T),其中[T any]就标识这个函数是一个泛型函数。

  • 多个类型参数

    func Add[T int | float64](a, b T) T {
       return a + b
    }
    
    fmt.Println(Add(1, 2))
    fmt.Println(Add(1.5, 2.3))
    

    在上面的代码中,我们传入了多个参数,同时我们可以看到,使用了[T int | float64]这样的格式。

    那么这种格式是什么意思呢?如果我们这样写,其实就代表这个函数接收的参数只能是intfloat64两种类型的。

    基于我们函数的功能,如果传入string结构体等类型的参数,那么肯定是不符合的,所以我们可以在函数中就指定好传入的类型范围。


泛型结构体

在结构体中使用泛型也是比较常用的一个操作,比如我们的结构体字段是相同的,但是会接收不同类型的值,那么使用泛型也是一个很好的选择。

type Container[T any] struct {
    value T
}

intContainer := Container[int]{value: 42}
fmt.Println(intContainer.value)

stringContainer := Container[string]{value: "hello"}
fmt.Println(stringContainer.value)

结构体泛型使用会比较广泛,特别是在一些算法或数据结构类型的场景。比如说实现一个栈、一个队列,那么我们就可以使用泛型来实现,这样栈就可以存储多种数据类型。

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() T {
    n := len(s.items)
    item := s.items[n-1]
    s.items = s.items[:n-1]
    return item
}

// 创建一个整数栈
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
fmt.Println(intStack.Pop())

// 创建一个字符串栈
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println(stringStack.Pop())

通过泛型我们就可以简单的进行处理,这种方法其实是比interface{}高效很多的。


泛型的约束与接口

  • 基本泛型约束

      // Compare 约束 T 必须可比较(类型必须实现了comparable接口)
      func Compare[T comparable](a, b T) bool {
          return a == b
      }
    
      fmt.Println(Compare(1, 2))
      fmt.Println(Compare("Go", "Go"))
    

    在上面的代码中,T类型参数使用了comparable作为约束,表示T必须是支持比较操作的类型,例如整数、字符串等。

    comparableGo的内置接口,用于表示可以比较的类型(支持 ==!= 操作)。

  • 自定义接口作为约束

      // Stringer 定义一个接口
      type Stringer interface {
          String() string
      }
    
      // PrintString 泛型函数,T 必须实现 Stringer 接口
      func PrintString[T Stringer](item T) {
          fmt.Println(item.String())
      }
    
      // Person 实现 Stringer 接口的类型
      type Person struct {
          Name string
      }
    
      func (p Person) String() string {
          return p.Name
      }
    

    在这个例子中,泛型函数PrintString限定类型参数T必须实现Stringer接口。

    也就是说PrintString只能用于那些实现了Stringer接口的类型,比如Person

  • 内置的泛型约束

      // Number 泛型约束 T 必须是 int 类型的别名
      type Number interface {
          ~int
      }
    
      func Sum[T Number](a, b T) T {
          return a + b
      }
    
      type MyInt int // MyInt 是 int 的别名
    
      var a MyInt = 10
      var b MyInt = 20
      fmt.Println(Sum(a, b))
    

    在上面的代码中,~int表示类型参数T可以是int或任何int的别名类型(如 MyInt)。

实现原理

如果了解Java的话我们可以知道,Java只要函数参数的类型不同,那么函数名称可以是相同的。

Go语言中,泛型其实差不多就是这么实现的。在编译的时候,编译器会生成多个类型的函数。

如下代码所示:

func Print[T any](input T) {
    fmt.Println(input)
}

Print(1)
Print("hello")

在上面的代码中,我们实现了一个简单的打印函数,调用的时候传入了intstring类型的数据。

那么在我们编译的时候,编译器可能会生成下面的代码:

func Print1[int](input int) {
    fmt.Println(input)
}

func Print2[string](input string) {
    fmt.Println(input)
}

执行的大概示意图如下所示:

泛型函数 Print[T]
+-----------------+
|    泛型代码      |
+-----------------+
        |
    Monomorphization
 (为不同值类型生成副本)
        |
  +------------+
  | PrintInt() |   // 为 int 类型生成的函数副本
  +------------+
  | PrintStr() |   // 为 string 类型生成的函数副本
  +------------+
  | PrintF64() |   // 为 float64 类型生成的函数副本
  +------------+

Go的泛型使用了多种方式,上面描述的属于其中的一种方式,也就是单态化,这种方式主要应用于参数是的函数。


上面我们提到的这种方式虽然简单,但是如果函数副本太多的话,最终编译出来的二进制文件肯定是很大的,所以还采用了虚拟方法表的方式。

当泛型函数接收的是指针类型或接口类型时,编译器会为它生成一个字典表。这个表类似于虚拟方法表,记录了如何在运行时处理不同类型的操作。

我们可以通过下面的示意图来理解:

编译时:
+--------------------------------------------+
|  编译器检查到 Person 实现了 Stringer 接口  |
+--------------------------------------------+
        |
        v
+-----------------------------------------+
|  生成 Person 的虚拟方法表(VMT)         |
|  包含 String() 指向 Person.String 的指针  |
+-----------------------------------------+

运行时:
+-------------------------------------+
|  调用 PrintString(p)                |
+-------------------------------------+
        |
        v
+---------------------------+
|  查找 p 的虚拟方法表       |  --> 找到 Person.String() 方法
+---------------------------+
        |
        v
+----------------------+
|  调用 Person.String() |
+----------------------+

虚拟方法表比较抽象,我们以一句话理解就可以:调用的时候,会去查找PrintString(p)p的方法表,最终找到了Person.String(),这时候就直接执行就可以了。

Go语言的泛型实现还在持续优化中,我们可以持续关注,现阶段掌握泛型的使用即可。

小结

本节我们讲解了泛型的基础概念、使用以及简单的实现原理。泛型为Go语言带来了更大的灵活性,帮助开发者编写更具通用性的代码。

在框架开发、工具开发场景应用比较广泛,通过泛型我们可以简单的将代码合并优化。

results matching ""

    No results matching ""